/*
 * Copyright (c) 2012-2017 Red Hat, Inc.
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License v1.0
 * which accompanies this distribution, and is available at
 * http://www.eclipse.org/legal/epl-v10.html
 *
 * Contributors:
 *   Red Hat, Inc. - initial API and implementation
 */
package org.eclipse.che.api.workspace.server;

import static com.google.common.base.MoreObjects.firstNonNull;
import static com.google.common.base.Throwables.getCausalChain;
import static java.lang.Boolean.parseBoolean;
import static java.lang.String.format;
import static java.lang.System.currentTimeMillis;
import static java.util.Collections.emptyMap;
import static java.util.Objects.requireNonNull;
import static org.eclipse.che.api.core.model.workspace.WorkspaceStatus.RUNNING;
import static org.eclipse.che.api.workspace.shared.Constants.AUTO_CREATE_SNAPSHOT;
import static org.eclipse.che.api.workspace.shared.Constants.AUTO_RESTORE_FROM_SNAPSHOT;
import static org.eclipse.che.api.workspace.shared.Constants.WORKSPACE_STOPPED_BY;

import com.google.inject.Inject;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import javax.inject.Named;
import javax.inject.Singleton;
import org.eclipse.che.account.api.AccountManager;
import org.eclipse.che.account.shared.model.Account;
import org.eclipse.che.api.agent.server.exception.AgentException;
import org.eclipse.che.api.core.ApiException;
import org.eclipse.che.api.core.BadRequestException;
import org.eclipse.che.api.core.ConflictException;
import org.eclipse.che.api.core.NotFoundException;
import org.eclipse.che.api.core.ServerException;
import org.eclipse.che.api.core.model.machine.MachineConfig;
import org.eclipse.che.api.core.model.workspace.Workspace;
import org.eclipse.che.api.core.model.workspace.WorkspaceConfig;
import org.eclipse.che.api.core.model.workspace.WorkspaceStatus;
import org.eclipse.che.api.core.notification.EventService;
import org.eclipse.che.api.environment.server.exception.EnvironmentException;
import org.eclipse.che.api.machine.server.exception.SnapshotException;
import org.eclipse.che.api.machine.server.exception.SourceNotFoundException;
import org.eclipse.che.api.machine.server.model.impl.SnapshotImpl;
import org.eclipse.che.api.machine.server.spi.Instance;
import org.eclipse.che.api.machine.server.spi.SnapshotDao;
import org.eclipse.che.api.workspace.server.event.WorkspaceCreatedEvent;
import org.eclipse.che.api.workspace.server.model.impl.WorkspaceConfigImpl;
import org.eclipse.che.api.workspace.server.model.impl.WorkspaceImpl;
import org.eclipse.che.api.workspace.server.spi.WorkspaceDao;
import org.eclipse.che.api.workspace.shared.dto.event.WorkspaceStatusEvent.EventType;
import org.eclipse.che.commons.annotation.Nullable;
import org.eclipse.che.commons.env.EnvironmentContext;
import org.eclipse.che.commons.subject.Subject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Facade for Workspace related operations.
 *
 * @author gazarenkov
 * @author Alexander Garagatyi
 * @author Yevhenii Voevodin
 * @author Igor Vinokur
 */
@Singleton
public class WorkspaceManager {

  private static final Logger LOG = LoggerFactory.getLogger(WorkspaceManager.class);

  /** This attribute describes time when workspace was created. */
  public static final String CREATED_ATTRIBUTE_NAME = "created";
  /** This attribute describes time when workspace was last updated or started/stopped/recovered. */
  public static final String UPDATED_ATTRIBUTE_NAME = "updated";

  /** Describes time when workspace was snapshotted. */
  public static final String SNAPSHOTTED_AT_ATTRIBUTE_NAME = "snapshotted_at";

  private final WorkspaceDao workspaceDao;
  private final SnapshotDao snapshotDao;
  private final WorkspaceRuntimes runtimes;
  private final AccountManager accountManager;
  private final WorkspaceSharedPool sharedPool;
  private final EventService eventService;
  private final boolean defaultAutoSnapshot;
  private final boolean defaultAutoRestore;

  @Inject
  public WorkspaceManager(
      WorkspaceDao workspaceDao,
      WorkspaceRuntimes workspaceRegistry,
      EventService eventService,
      AccountManager accountManager,
      @Named("che.workspace.auto_snapshot") boolean defaultAutoSnapshot,
      @Named("che.workspace.auto_restore") boolean defaultAutoRestore,
      SnapshotDao snapshotDao,
      WorkspaceSharedPool sharedPool) {
    this.workspaceDao = workspaceDao;
    this.snapshotDao = snapshotDao;
    this.runtimes = workspaceRegistry;
    this.accountManager = accountManager;
    this.eventService = eventService;
    this.defaultAutoSnapshot = defaultAutoSnapshot;
    this.defaultAutoRestore = defaultAutoRestore;
    this.sharedPool = sharedPool;
  }

  /**
   * Creates a new {@link WorkspaceImpl} instance based on the given configuration.
   *
   * @param config the workspace config to create the new workspace instance
   * @param namespace workspace name is unique in this namespace
   * @return new workspace instance
   * @throws NullPointerException when either {@code config} or {@code owner} is null
   * @throws NotFoundException when account with given id was not found
   * @throws ConflictException when any conflict occurs (e.g Workspace with such name already exists
   *     for {@code owner})
   * @throws ServerException when any other error occurs
   */
  public WorkspaceImpl createWorkspace(WorkspaceConfig config, String namespace)
      throws ServerException, ConflictException, NotFoundException {
    requireNonNull(config, "Required non-null config");
    requireNonNull(namespace, "Required non-null namespace");
    WorkspaceImpl workspace =
        doCreateWorkspace(config, accountManager.getByName(namespace), emptyMap(), false);
    workspace.setStatus(WorkspaceStatus.STOPPED);
    return workspace;
  }

  /**
   * Creates a new {@link Workspace} instance based on the given configuration and the instance
   * attributes.
   *
   * @param config the workspace config to create the new workspace instance
   * @param namespace workspace name is unique in this namespace
   * @param attributes workspace instance attributes
   * @return new workspace instance
   * @throws NullPointerException when either {@code config} or {@code owner} is null
   * @throws NotFoundException when account with given id was not found
   * @throws ConflictException when any conflict occurs (e.g Workspace with such name already exists
   *     for {@code owner})
   * @throws ServerException when any other error occurs
   */
  public WorkspaceImpl createWorkspace(
      WorkspaceConfig config, String namespace, Map<String, String> attributes)
      throws ServerException, NotFoundException, ConflictException {
    requireNonNull(config, "Required non-null config");
    requireNonNull(namespace, "Required non-null namespace");
    requireNonNull(attributes, "Required non-null attributes");
    WorkspaceImpl workspace =
        doCreateWorkspace(config, accountManager.getByName(namespace), attributes, false);
    workspace.setStatus(WorkspaceStatus.STOPPED);
    return workspace;
  }

  /**
   * Gets workspace by composite key.
   *
   * <p>Key rules:
   *
   * <ul>
   *   <li>@Deprecated : If it contains <b>:</b> character then that key is combination of namespace
   *       and workspace name
   *   <li>@Deprecated : <b></>:workspace_name</b> is valid abstract key and current user name will
   *       be used as namespace
   *   <li>If it doesn't contain <b>/</b> character then that key is id(e.g. workspace123456)
   *   <li>If it contains <b>/</b> character then that key is combination of namespace and workspace
   *       name
   * </ul>
   *
   * Note that namespace can contain <b>/</b> character.
   *
   * @param key composite key(e.g. workspace 'id' or 'namespace/name')
   * @return the workspace instance
   * @throws NullPointerException when {@code key} is null
   * @throws NotFoundException when workspace doesn't exist
   * @throws ServerException when any server error occurs
   */
  public WorkspaceImpl getWorkspace(String key) throws NotFoundException, ServerException {
    requireNonNull(key, "Required non-null workspace key");
    WorkspaceImpl workspace = getByKey(key);
    runtimes.injectRuntime(workspace);
    addExtraAttributes(workspace);
    return workspace;
  }

  /**
   * Gets workspace by name and owner.
   *
   * <p>Returned instance status is either {@link WorkspaceStatus#STOPPED} or defined by its
   * runtime(if exists).
   *
   * @param name the name of the workspace
   * @param namespace the owner of the workspace
   * @return the workspace instance
   * @throws NotFoundException when workspace with such id doesn't exist
   * @throws ServerException when any server error occurs
   */
  public WorkspaceImpl getWorkspace(String name, String namespace)
      throws NotFoundException, ServerException {
    requireNonNull(name, "Required non-null workspace name");
    requireNonNull(namespace, "Required non-null workspace owner");
    WorkspaceImpl workspace = workspaceDao.get(name, namespace);
    runtimes.injectRuntime(workspace);
    addExtraAttributes(workspace);
    return workspace;
  }

  /**
   * Gets list of workspaces which user can read. Runtimes are included
   *
   * @param user the id of the user
   * @return the list of workspaces or empty list if user can't read any workspace
   * @throws NullPointerException when {@code user} is null
   * @throws ServerException when any server error occurs while getting workspaces with {@link
   *     WorkspaceDao#getWorkspaces(String)}
   * @deprecated use #getWorkspaces(String user, boolean includeRuntimes) instead
   */
  @Deprecated
  public List<WorkspaceImpl> getWorkspaces(String user) throws ServerException {
    return getWorkspaces(user, true);
  }

  /**
   * Gets list of workspaces which user can read
   *
   * <p>Returned workspaces have either {@link WorkspaceStatus#STOPPED} status or status defined by
   * their runtime instances(if those exist).
   *
   * @param user the id of the user
   * @param includeRuntimes if <code>true</code>, will fetch runtime info for workspaces. If <code>
   *     false</code>, will not fetch runtime info.
   * @return the list of workspaces or empty list if user can't read any workspace
   * @throws NullPointerException when {@code user} is null
   * @throws ServerException when any server error occurs while getting workspaces with {@link
   *     WorkspaceDao#getWorkspaces(String)}
   */
  public List<WorkspaceImpl> getWorkspaces(String user, boolean includeRuntimes)
      throws ServerException {
    requireNonNull(user, "Required non-null user id");
    final List<WorkspaceImpl> workspaces = workspaceDao.getWorkspaces(user);
    injectRuntimeAndAttributes(workspaces, !includeRuntimes);
    return workspaces;
  }

  /**
   * Gets list of workspaces which has given namespace. Runtimes are included
   *
   * @param namespace the namespace to find workspaces
   * @return the list of workspaces or empty list if no matches
   * @throws NullPointerException when {@code namespace} is null
   * @throws ServerException when any server error occurs while getting workspaces with {@link
   *     WorkspaceDao#getByNamespace(String)}
   * @deprecated use #getByNamespace(String user, boolean includeRuntimes) instead
   */
  @Deprecated
  public List<WorkspaceImpl> getByNamespace(String namespace) throws ServerException {
    return getByNamespace(namespace, true);
  }

  /**
   * Gets list of workspaces which has given namespace
   *
   * <p>Returned workspaces have either {@link WorkspaceStatus#STOPPED} status or status defined by
   * their runtime instances(if those exist).
   *
   * @param namespace the namespace to find workspaces
   * @param includeRuntimes if <code>true</code>, will fetch runtime info for workspaces. If <code>
   *     false</code>, will not fetch runtime info.
   * @return the list of workspaces or empty list if no matches
   * @throws NullPointerException when {@code namespace} is null
   * @throws ServerException when any server error occurs while getting workspaces with {@link
   *     WorkspaceDao#getByNamespace(String)}
   */
  public List<WorkspaceImpl> getByNamespace(String namespace, boolean includeRuntimes)
      throws ServerException {
    requireNonNull(namespace, "Required non-null namespace");
    final List<WorkspaceImpl> workspaces = workspaceDao.getByNamespace(namespace);
    injectRuntimeAndAttributes(workspaces, !includeRuntimes);
    return workspaces;
  }

  /**
   * Updates an existing workspace with a new configuration.
   *
   * <p>Replace strategy is used for workspace update, it means that existing workspace data will be
   * replaced with given {@code update}.
   *
   * @param update workspace update
   * @return updated instance of the workspace
   * @throws NullPointerException when either {@code workspaceId} or {@code update} is null
   * @throws NotFoundException when workspace with given id doesn't exist
   * @throws ConflictException when any conflict occurs (e.g Workspace with such name already exists
   *     in {@code namespace})
   * @throws ServerException when any other error occurs
   */
  public WorkspaceImpl updateWorkspace(String id, Workspace update)
      throws ConflictException, ServerException, NotFoundException {
    requireNonNull(id, "Required non-null workspace id");
    requireNonNull(update, "Required non-null workspace update");
    WorkspaceImpl workspace = workspaceDao.get(id);
    workspace.setConfig(new WorkspaceConfigImpl(update.getConfig()));
    update.getAttributes().put(UPDATED_ATTRIBUTE_NAME, Long.toString(currentTimeMillis()));
    workspace.setAttributes(update.getAttributes());
    workspace.setTemporary(update.isTemporary());
    WorkspaceImpl updated = workspaceDao.update(workspace);
    runtimes.injectRuntime(updated);
    return updated;
  }

  /**
   * Removes workspace with specified identifier.
   *
   * <p>Does not remove the workspace if it has the runtime, throws {@link ConflictException} in
   * this case. Won't throw any exception if workspace doesn't exist.
   *
   * @param workspaceId workspace id to remove workspace
   * @throws ConflictException when workspace has runtime
   * @throws ServerException when any server error occurs
   * @throws NullPointerException when {@code workspaceId} is null
   */
  public void removeWorkspace(String workspaceId) throws ConflictException, ServerException {
    requireNonNull(workspaceId, "Required non-null workspace id");
    if (runtimes.hasRuntime(workspaceId)) {
      throw new ConflictException(
          format("The workspace '%s' is currently running and cannot be removed.", workspaceId));
    }

    workspaceDao.remove(workspaceId);
    LOG.info("Workspace '{}' removed by user '{}'", workspaceId, sessionUserNameOr("undefined"));
  }

  /**
   * Asynchronously starts certain workspace with specified environment and account.
   *
   * @param workspaceId identifier of workspace which should be started
   * @param envName name of environment or null, when default environment should be used
   * @param restore if <code>true</code> workspace will be restored from snapshot if snapshot
   *     exists, otherwise (if snapshot does not exist) workspace will be started from default
   *     source. If <code>false</code> workspace will be started from default source, even if
   *     auto-restore is enabled and snapshot exists. If <code>null</code> workspace will be
   *     restored from snapshot only if workspace has `auto-restore` attribute set to <code>true
   *     </code>, or system wide parameter `auto-restore` is enabled and snapshot exists.
   *     <p>This parameter has the highest priority to define if it is needed to restore from
   *     snapshot or not. If it is not defined workspace `auto-restore` attribute will be checked,
   *     then if last is not defined system wide `auto-restore` parameter will be checked.
   * @return starting workspace
   * @throws NullPointerException when {@code workspaceId} is null
   * @throws NotFoundException when workspace with given {@code workspaceId} doesn't exist
   * @throws ServerException when any other error occurs during workspace start
   */
  public WorkspaceImpl startWorkspace(
      String workspaceId, @Nullable String envName, @Nullable Boolean restore)
      throws NotFoundException, ServerException, ConflictException {
    requireNonNull(workspaceId, "Required non-null workspace id");
    final WorkspaceImpl workspace = workspaceDao.get(workspaceId);
    final String restoreAttr = workspace.getAttributes().get(AUTO_RESTORE_FROM_SNAPSHOT);
    final boolean autoRestore =
        restoreAttr == null ? defaultAutoRestore : parseBoolean(restoreAttr);
    startAsync(
        workspace,
        envName,
        firstNonNull(restore, autoRestore) && !getSnapshot(workspaceId).isEmpty());
    runtimes.injectRuntime(workspace);
    addExtraAttributes(workspace);
    return workspace;
  }

  /**
   * Asynchronously starts workspace from the given configuration.
   *
   * @param config workspace configuration from which workspace is created and started
   * @param namespace workspace name is unique in this namespace
   * @return starting workspace
   * @throws NullPointerException when {@code workspaceId} is null
   * @throws NotFoundException when workspace with given {@code workspaceId} doesn't exist
   * @throws ServerException when any other error occurs during workspace start
   */
  public WorkspaceImpl startWorkspace(WorkspaceConfig config, String namespace, boolean isTemporary)
      throws ServerException, NotFoundException, ConflictException {
    requireNonNull(config, "Required non-null configuration");
    requireNonNull(namespace, "Required non-null namespace");
    final WorkspaceImpl workspace =
        doCreateWorkspace(config, accountManager.getByName(namespace), emptyMap(), isTemporary);
    startAsync(workspace, workspace.getConfig().getDefaultEnv(), false);
    runtimes.injectRuntime(workspace);
    return workspace;
  }

  /**
   * Starts machine in running workspace
   *
   * @param machineConfig configuration of machine to start
   * @param workspaceId id of workspace in which machine should be started
   * @throws NotFoundException if machine type from recipe is unsupported
   * @throws NotFoundException if no instance provider implementation found for provided machine
   *     type
   * @throws ConflictException if machine with given name already exists
   * @throws ConflictException if workspace is not in RUNNING state
   * @throws BadRequestException if machine name is invalid
   * @throws ServerException if any other exception occurs during starting
   */
  public void startMachine(MachineConfig machineConfig, String workspaceId)
      throws ServerException, ConflictException, BadRequestException, NotFoundException {

    final WorkspaceImpl workspace = getWorkspace(workspaceId);
    if (RUNNING != workspace.getStatus()) {
      throw new ConflictException(
          format("Workspace '%s' is not running, new machine can't be started", workspaceId));
    }

    startAsync(machineConfig, workspaceId);
  }

  /**
   * Asynchronously stops the workspace.
   *
   * @param workspaceId the id of the workspace to stop
   * @throws ServerException when any server error occurs
   * @throws NullPointerException when {@code workspaceId} is null
   * @throws NotFoundException when workspace {@code workspaceId} doesn't have runtime
   */
  public void stopWorkspace(String workspaceId)
      throws ServerException, NotFoundException, ConflictException {
    stopWorkspace(workspaceId, null);
  }

  /**
   * Asynchronously stops the workspace, creates a snapshot of it if {@code createSnapshot} is set
   * to true.
   *
   * @param workspaceId the id of the workspace to stop
   * @param createSnapshot true if create snapshot, false if don't, null if default behaviour should
   *     be used
   * @throws ServerException when any server error occurs
   * @throws NullPointerException when {@code workspaceId} is null
   * @throws NotFoundException when workspace {@code workspaceId} doesn't have runtime
   */
  public void stopWorkspace(String workspaceId, @Nullable Boolean createSnapshot)
      throws ConflictException, NotFoundException, ServerException {
    requireNonNull(workspaceId, "Required non-null workspace id");
    final WorkspaceImpl workspace = workspaceDao.get(workspaceId);
    workspace.setStatus(runtimes.getStatus(workspaceId));
    if (workspace.getStatus() != WorkspaceStatus.RUNNING
        && workspace.getStatus() != WorkspaceStatus.STARTING) {
      throw new ConflictException(
          format(
              "Could not stop the workspace '%s/%s' because its status is '%s'. "
                  + "Workspace must be either 'STARTING' or 'RUNNING'",
              workspace.getNamespace(), workspace.getConfig().getName(), workspace.getStatus()));
    }
    stopAsync(workspace, createSnapshot);
  }

  /**
   * Creates snapshot of runtime workspace.
   *
   * <p>Basically creates {@link SnapshotImpl snapshot} instance for each machine from runtime
   * workspace's active environment.
   *
   * <p>If snapshot of workspace's dev machine was created successfully publishes {@link
   * EventType#SNAPSHOT_CREATED} event, otherwise publishes {@link
   * EventType#SNAPSHOT_CREATION_ERROR} with appropriate error message.
   *
   * <p>Note that: <br>
   * Snapshots are created asynchronously <br>
   * If snapshot creation for one machine failed, it wouldn't affect another snapshot creations
   *
   * @param workspaceId runtime workspace id
   * @throws NullPointerException when {@code workspaceId} is null
   * @throws NotFoundException when runtime workspace with given id does not exist
   * @throws ServerException when any other error occurs
   * @throws ConflictException when workspace is not running
   */
  public void createSnapshot(String workspaceId)
      throws NotFoundException, ServerException, ConflictException {
    requireNonNull(workspaceId, "Required non-null workspace id");
    runtimes.snapshotAsync(workspaceId);
  }

  /**
   * Returns list of machine snapshots which are related to workspace with given id.
   *
   * @param workspaceId workspace id to get snapshot
   * @return list of machine snapshots related to given workspace
   * @throws NullPointerException when {@code workspaceId} is null
   * @throws NotFoundException when workspace with given id doesn't exists
   * @throws ServerException when any other error occurs
   */
  public List<SnapshotImpl> getSnapshot(String workspaceId)
      throws ServerException, NotFoundException {
    requireNonNull(workspaceId, "Required non-null workspace id");
    // check if workspace exists
    workspaceDao.get(workspaceId);
    return snapshotDao.findSnapshots(workspaceId);
  }

  /**
   * Removes all snapshots of workspace machines, continues to remove snapshots even when removal of
   * some of them fails.
   *
   * <p>Note that snapshots binaries are removed asynchronously while metadata removal is
   * synchronous operation.
   *
   * @param workspaceId workspace id to remove machine snapshots
   * @throws NotFoundException when workspace with given id doesn't exists
   * @throws ServerException when any other error occurs
   */
  public void removeSnapshots(String workspaceId) throws NotFoundException, ServerException {
    List<SnapshotImpl> snapshots = getSnapshot(workspaceId);
    List<SnapshotImpl> removed = new ArrayList<>(snapshots.size());
    for (SnapshotImpl snapshot : snapshots) {
      try {
        snapshotDao.removeSnapshot(snapshot.getId());
        removed.add(snapshot);
      } catch (Exception x) {
        LOG.error(
            format(
                "Couldn't remove snapshot '%s' meta data, " + "binaries won't be removed either",
                snapshot.getId()),
            x);
      }
    }
    // binaries removal may take some time, do it asynchronously
    sharedPool.execute(() -> runtimes.removeBinaries(removed));
  }

  /**
   * Stops machine in running workspace.
   *
   * @param workspaceId ID of workspace that owns machine
   * @param machineId ID of machine that should be stopped
   * @throws NotFoundException if machine is not found in running workspace
   * @throws ConflictException if workspace is not running
   * @throws ConflictException if machine stop is forbidden (e.g. machine is dev-machine)
   * @throws ServerException if other error occurs
   */
  public void stopMachine(String workspaceId, String machineId)
      throws NotFoundException, ServerException, ConflictException {
    requireNonNull(workspaceId, "Required non-null workspace id");
    requireNonNull(machineId, "Required non-null machine id");
    final WorkspaceImpl workspace = workspaceDao.get(workspaceId);
    workspace.setStatus(runtimes.getStatus(workspaceId));
    checkWorkspaceIsRunning(workspace, format("stop machine with ID '%s' of", machineId));
    runtimes.stopMachine(workspaceId, machineId);
  }

  /**
   * Retrieves machine instance that allows to execute commands in a machine.
   *
   * @param workspaceId ID of workspace that owns machine
   * @param machineId ID of requested machine
   * @return instance of requested machine
   * @throws NotFoundException if workspace is not running
   * @throws NotFoundException if machine is not found in the workspace
   */
  public Instance getMachineInstance(String workspaceId, String machineId)
      throws NotFoundException, ServerException {
    requireNonNull(workspaceId, "Required non-null workspace id");
    requireNonNull(machineId, "Required non-null machine id");
    workspaceDao.get(workspaceId);
    return runtimes.getMachine(workspaceId, machineId);
  }

  /**
   * Shuts down workspace service and waits for it to finish, so currently starting and running
   * workspaces are stopped and it becomes unavailable to start new workspaces.
   *
   * @throws InterruptedException if it's interrupted while waiting for running workspaces to stop
   * @throws IllegalStateException if component shutdown is already called
   */
  public void shutdown() throws InterruptedException {
    if (!runtimes.refuseWorkspacesStart()) {
      throw new IllegalStateException("Workspace service shutdown has been already called");
    }
    stopRunningWorkspacesNormally();
    runtimes.shutdown();
    sharedPool.shutdown();
  }

  /** Returns set of workspace ids that are not {@link WorkspaceStatus#STOPPED}. */
  public Set<String> getRunningWorkspacesIds() {
    return runtimes.getRuntimesIds();
  }

  /**
   * Stops all the running and starting workspaces - snapshotting them before if needed. Workspace
   * stop operations executed asynchronously while the method waits for async task to finish.
   */
  private void stopRunningWorkspacesNormally() throws InterruptedException {
    if (runtimes.isAnyRunning()) {

      // getting all the running or starting workspaces
      ArrayList<WorkspaceImpl> runningOrStarting = new ArrayList<>();
      for (String workspaceId : runtimes.getRuntimesIds()) {
        try {
          WorkspaceImpl workspace = workspaceDao.get(workspaceId);
          workspace.setStatus(runtimes.getStatus(workspaceId));
          if (workspace.getStatus() == WorkspaceStatus.RUNNING
              || workspace.getStatus() == WorkspaceStatus.STARTING) {
            runningOrStarting.add(workspace);
          }
        } catch (NotFoundException | ServerException x) {
          if (runtimes.hasRuntime(workspaceId)) {
            LOG.error(
                "Couldn't get the workspace '{}' while it's running, the occurred error: '{}'",
                workspaceId,
                x.getMessage());
          }
        }
      }

      // stopping them asynchronously
      CountDownLatch stopLatch = new CountDownLatch(runningOrStarting.size());
      for (WorkspaceImpl workspace : runningOrStarting) {
        try {
          stopAsync(workspace, null).whenComplete((res, ex) -> stopLatch.countDown());
        } catch (Exception x) {
          stopLatch.countDown();
          if (runtimes.hasRuntime(workspace.getId())) {
            LOG.warn(
                "Couldn't stop the workspace '{}' normally, due to error: {}",
                workspace.getId(),
                x.getMessage());
          }
        }
      }

      // wait for stopping workspaces to complete
      stopLatch.await();
    }
  }

  /** Asynchronously starts given workspace. */
  private void startAsync(WorkspaceImpl workspace, String envName, boolean recover)
      throws ConflictException, NotFoundException, ServerException {
    if (envName != null && !workspace.getConfig().getEnvironments().containsKey(envName)) {
      throw new NotFoundException(
          format(
              "Workspace '%s/%s' doesn't contain environment '%s'",
              workspace.getNamespace(), workspace.getConfig().getName(), envName));
    }
    workspace.getAttributes().put(UPDATED_ATTRIBUTE_NAME, Long.toString(currentTimeMillis()));
    workspaceDao.update(workspace);
    final String env = firstNonNull(envName, workspace.getConfig().getDefaultEnv());

    runtimes
        .startAsync(workspace, env, recover)
        .whenComplete(
            (runtime, ex) -> {
              if (ex == null) {
                LOG.info(
                    "Workspace '{}/{}' with id '{}' started by user '{}'",
                    workspace.getNamespace(),
                    workspace.getConfig().getName(),
                    workspace.getId(),
                    sessionUserNameOr("undefined"));
              } else {
                if (workspace.isTemporary()) {
                  removeWorkspaceQuietly(workspace);
                }
                for (Throwable cause : getCausalChain(ex)) {
                  if (cause instanceof SourceNotFoundException) {
                    return;
                  }
                }
                try {
                  throw ex;
                } catch (EnvironmentException | AgentException e) {
                  // it's okay, e.g. recipe is invalid | start interrupted | agent start failed
                  LOG.info(
                      "Workspace '{}/{}' can't be started because: {}",
                      workspace.getNamespace(),
                      workspace.getConfig().getName(),
                      e.getMessage());
                } catch (Throwable thr) {
                  LOG.error(thr.getMessage(), thr);
                }
              }
            });
  }

  private CompletableFuture<Void> stopAsync(
      WorkspaceImpl workspace, @Nullable Boolean createSnapshot)
      throws ConflictException, NotFoundException, ServerException {
    if (!workspace.isTemporary()) {
      workspace.getAttributes().put(UPDATED_ATTRIBUTE_NAME, Long.toString(currentTimeMillis()));
      workspaceDao.update(workspace);
    }
    return sharedPool.runAsync(
        () -> {
          final String stoppedBy =
              sessionUserNameOr(workspace.getAttributes().get(WORKSPACE_STOPPED_BY));
          LOG.info(
              "Workspace '{}/{}' with id '{}' is being stopped by user '{}'",
              workspace.getNamespace(),
              workspace.getConfig().getName(),
              workspace.getId(),
              firstNonNull(stoppedBy, "undefined"));

          final boolean snapshotBeforeStop;
          if (workspace.isTemporary() || workspace.getStatus() == WorkspaceStatus.STARTING) {
            snapshotBeforeStop = false;
          } else if (createSnapshot != null) {
            snapshotBeforeStop = createSnapshot;
          } else if (workspace.getAttributes().containsKey(AUTO_CREATE_SNAPSHOT)) {
            snapshotBeforeStop = parseBoolean(workspace.getAttributes().get(AUTO_CREATE_SNAPSHOT));
          } else {
            snapshotBeforeStop = defaultAutoSnapshot;
          }

          if (snapshotBeforeStop) {
            try {
              runtimes.snapshot(workspace.getId());
            } catch (ConflictException | NotFoundException | ServerException x) {
              LOG.warn(
                  "Could not create a snapshot of the workspace '{}/{}' "
                      + "with workspace id '{}'. The workspace will be stopped",
                  workspace.getNamespace(),
                  workspace.getConfig().getName(),
                  workspace.getId());
            }
          }

          try {
            runtimes.stop(workspace.getId());
            LOG.info(
                "Workspace '{}/{}' with id '{}' stopped by user '{}'",
                workspace.getNamespace(),
                workspace.getConfig().getName(),
                workspace.getId(),
                firstNonNull(stoppedBy, "undefined"));
          } catch (Exception ex) {
            LOG.error(ex.getLocalizedMessage(), ex);
          } finally {
            if (workspace.isTemporary()) {
              removeWorkspaceQuietly(workspace);
            }
          }
        });
  }

  private void startAsync(MachineConfig machineConfig, String workspaceId) {
    sharedPool.execute(
        () -> {
          try {
            runtimes.startMachine(workspaceId, machineConfig);
          } catch (AgentException e) {
            // Agent start failed. User should fix that. No need to disturb an admin
            LOG.warn(
                "Error occurs on start of additional machine in workspace %s. Error: %s",
                workspaceId, e.getLocalizedMessage());
          } catch (ApiException | EnvironmentException e) {
            LOG.error(e.getLocalizedMessage(), e);
          }
        });
  }

  private void checkWorkspaceIsRunning(WorkspaceImpl workspace, String operation)
      throws ConflictException {
    if (workspace.getStatus() != RUNNING) {
      throw new ConflictException(
          format(
              "Could not %s the workspace '%s/%s' because its status is '%s'.",
              operation,
              workspace.getNamespace(),
              workspace.getConfig().getName(),
              workspace.getStatus()));
    }
  }

  private void removeWorkspaceQuietly(Workspace workspace) {
    try {
      workspaceDao.remove(workspace.getId());
    } catch (ServerException x) {
      LOG.error("Unable to remove temporary workspace '{}'", workspace.getId());
    }
  }

  private String sessionUserNameOr(String nameIfNoUser) {
    final Subject subject = EnvironmentContext.getCurrent().getSubject();
    if (!subject.isAnonymous()) {
      return subject.getUserName();
    }
    return nameIfNoUser;
  }

  private WorkspaceImpl doCreateWorkspace(
      WorkspaceConfig config, Account account, Map<String, String> attributes, boolean isTemporary)
      throws NotFoundException, ServerException, ConflictException {
    final WorkspaceImpl workspace =
        WorkspaceImpl.builder()
            .generateId()
            .setConfig(config)
            .setAccount(account)
            .setAttributes(attributes)
            .setTemporary(isTemporary)
            .build();
    workspace.getAttributes().put(CREATED_ATTRIBUTE_NAME, Long.toString(currentTimeMillis()));
    workspaceDao.create(workspace);
    LOG.info(
        "Workspace '{}/{}' with id '{}' created by user '{}'",
        account.getName(),
        workspace.getConfig().getName(),
        workspace.getId(),
        sessionUserNameOr("undefined"));
    eventService.publish(new WorkspaceCreatedEvent(workspace));
    return workspace;
  }

  private WorkspaceImpl getByKey(String key) throws NotFoundException, ServerException {

    int lastColonIndex = key.indexOf(":");
    int lastSlashIndex = key.lastIndexOf("/");
    if (lastSlashIndex == -1 && lastColonIndex == -1) {
      // key is id
      return workspaceDao.get(key);
    }

    final String namespace;
    final String wsName;
    if (lastColonIndex == 0) {
      // no namespace, use current user namespace
      namespace = EnvironmentContext.getCurrent().getSubject().getUserName();
      wsName = key.substring(1);
    } else if (lastColonIndex > 0) {
      wsName = key.substring(lastColonIndex + 1);
      namespace = key.substring(0, lastColonIndex);
    } else {
      namespace = key.substring(0, lastSlashIndex);
      wsName = key.substring(lastSlashIndex + 1);
    }
    return workspaceDao.get(wsName, namespace);
  }

  /**
   * Adds runtime data (whole or status only) and extra attributes to each of the given workspaces.
   */
  private void injectRuntimeAndAttributes(List<WorkspaceImpl> workspaces, boolean statusOnly)
      throws SnapshotException {
    if (statusOnly) {
      for (WorkspaceImpl workspace : workspaces) {
        workspace.setStatus(runtimes.getStatus(workspace.getId()));
        addExtraAttributes(workspace);
      }
    } else {
      for (WorkspaceImpl workspace : workspaces) {
        runtimes.injectRuntime(workspace);
        addExtraAttributes(workspace);
      }
    }
  }

  /** Adds attributes that are not originally stored in workspace but should be published. */
  private void addExtraAttributes(WorkspaceImpl workspace) throws SnapshotException {
    // snapshotted_at
    List<SnapshotImpl> snapshots = snapshotDao.findSnapshots(workspace.getId());
    if (!snapshots.isEmpty()) {
      workspace
          .getAttributes()
          .put(SNAPSHOTTED_AT_ATTRIBUTE_NAME, Long.toString(snapshots.get(0).getCreationDate()));
    }
  }
}
